iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
自我挑戰組

Techschool Goalng Backend Master Class 的學習記錄系列 第 25 篇

[Day 25] Mock DB for testing HTTP API in Go and achieve 100% coverage Part 4

  • 分享至 

  • xImage
  •  

Achieve 100% coverage

  • 宣告測試案例

    • 使用匿名類別來存放測試數據。
    • 每個測試案例都應該有一個唯一的名稱。
  • 結構定義

    • 每個測試案例包含名稱、賬戶ID、建立模擬數據的函式(buildStubs)和檢查API回應的函式(checkResponse)。
    func TestGetAccountAPI(t *testing.T) {
        account := randomAccount()
    
        testCases := []struct {
            name          string
            accountID     int64
            buildStubs    func(store *mockdb.MockStore)
            checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
        }{
            // TODO: add test data
        }
        for i := range testCases {
            tc := testCases[i]
    
            t.Run(tc.name, func(t *testing.T) {
                ctrl := gomock.NewController(t)
                defer ctrl.Finish()
    
                store := mockdb.NewMockStore(ctrl)
                tc.buildStubs(store)
    
                server := NewServer(store)
                recorder := httptest.NewRecorder()
    
                url := fmt.Sprintf("/accounts/%d", tc.accountID)
                request, err := http.NewRequest(http.MethodGet, url, nil)
                require.NoError(t, err)
    
                server.router.ServeHTTP(recorder, request)
                tc.checkResponse(t, recorder)
            })
        }
    }
    
  • 成功的情境

    • 期望的HTTP回應狀態碼為http.StatusOK。
    • 使用模擬的資料庫回應來模仿成功的情境。
    func TestGetAccountAPI(t *testing.T) {
        account := randomAccount()
    
        testCases := []struct {
            name          string
            accountID     int64
            buildStubs    func(store *mockdb.MockStore)
            checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
        }{
            {
                name:      "OK",
                accountID: account.ID,
                buildStubs: func(store *mockdb.MockStore) {
                    store.EXPECT().
                        GetAccount(gomock.Any(), gomock.Eq(account.ID)).
                        Times(1).
                        Return(account, nil)
                },
                checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                    require.Equal(t, http.StatusOK, recorder.Code)
                    requireBodyMatchAccount(t, recorder.Body, account)
                },
            },
        }
        ...
    }
    
  • 賬戶未找到的情境

    • 期望的HTTP回應狀態碼為http.StatusNotFound。
    • 模擬資料庫回應sql.ErrNoRows表示賬戶不存在。
    • 因為NotFound,所以也不需要requireBodyMatchAccount來驗證Body
    func TestGetAccountAPI(t *testing.T) {
        account := randomAccount()
    
        testCases := []struct {
            name          string
            accountID     int64
            buildStubs    func(store *mockdb.MockStore)
            checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
        }{
            ...        
           {
                name:      "NotFound",
                accountID: account.ID,
                buildStubs: func(store *mockdb.MockStore) {
                    store.EXPECT().
                        GetAccount(gomock.Any(), gomock.Eq(account.ID)).
                        Times(1).
                        Return(db.Account{}, sql.ErrNoRows)
                },
                checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                    require.Equal(t, http.StatusNotFound, recorder.Code)
                },
            },
        }
    
        ...
    }
    
  • 伺服器內部錯誤情境

    • 期望的HTTP回應狀態碼為http.StatusInternalServerError。
    • 模擬資料庫回應sql.ErrConnDone,表示一個已結束的連接錯誤。
    func TestGetAccountAPI(t *testing.T) {
        account := randomAccount()
    
        testCases := []struct {
            name          string
            accountID     int64
            buildStubs    func(store *mockdb.MockStore)
            checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
        }{
            ...
            {
                name:      "InternalError",
                accountID: account.ID,
                buildStubs: func(store *mockdb.MockStore) {
                    store.EXPECT().
                        GetAccount(gomock.Any(), gomock.Eq(account.ID)).
                        Times(1).
                        Return(db.Account{}, sql.ErrConnDone)
                },
                checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                    require.Equal(t, http.StatusInternalServerError, recorder.Code)
                },
            },
        }
    
        ...
    }
    
  • 無效請求情境

    • 期望的HTTP回應狀態碼為http.StatusBadRequest。
    • 賬戶ID設定為0,模擬一個無效的賬戶ID請求。
    func TestGetAccountAPI(t *testing.T) {
        account := randomAccount()
    
        testCases := []struct {
            name          string
            accountID     int64
            buildStubs    func(store *mockdb.MockStore)
            checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
        }{
            ...
            {
                name:      "InvalidID",
                accountID: 0,
                buildStubs: func(store *mockdb.MockStore) {
                    store.EXPECT().
                        GetAccount(gomock.Any(), gomock.Any()).
                        Times(0)
                },
                checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
                    require.Equal(t, http.StatusBadRequest, recorder.Code)
                },
            },
        }
    
        ...
    }
    
  • 整體測試覆蓋率

    • 進行整套測試確保所有情境都被涵蓋,並達到100%的代碼覆蓋率。
  • 減少多餘的日誌輸出

    • Gin框架預設為Debug模式,會產生許多冗餘的日誌。
    • 在main_test.go中設置Gin為gin.TestMode以減少不必要的日誌。
    api/main_test.go
    func TestMain(m *testing.M) {
        gin.SetMode(gin.TestMode)
        os.Exit(m.Run())
    }
    

Q & A:

  1. 其中 checkResponse func(t *testing.T, recoder httptest.ResponseRecorder) 裡面的**httptest.ResponseRecorder 指就是respone body 和status code嗎 ?
    1. 是的,httptest.ResponseRecorder 是一個特殊的 http.ResponseWriter。它的主要目的是在單元測試中記錄 HTTP 回應,以便稍後檢查和驗證。當你使用它來"記錄" HTTP 回應時,它會保存以下內容:
      1. 狀態碼 (StatusCode):例如 200 (OK)、404 (NotFound) 等。
      2. 回應頭 (HeaderMap):這是一個映射,保存了回應的所有 HTTP 頭。
      3. 回應主體 (Body):一個保存了 HTTP 回應主體的緩衝區。
    2. 所以在你的測試中,recorder.Body 是一個包含 HTTP 回應主體的 *bytes.Buffer,而 recorder.Code 則是 HTTP 的狀態碼。
  2. t. Run的作用是什麼? 為何需要帶入tc.name ?
    1. 功能:
      1. t.Run 是 Go 的 testing 套件中的一個方法,用於執行子測試。
    2. 使用場景:
      1. 主要用於組織和執行表格驅動測試 (table-driven tests)。
    3. 表格驅動測試:
      1. 這是一種常見的測試模式,允許你使用一個資料表(如結構體切片)來定義多個測試案例。
      2. 通過迭代這些案例,可以依次執行每個測試。
    4. 子測試名稱:
      1. t.Run 的第一個參數是子測試的名稱,這裡使用的是 tc.name。
      2. 子測試的名稱在測試報告中必須是唯一的。
      3. 這樣做的目的是在測試結果中能夠快速識別和定位特定的子測試。
    5. 優點:
      1. 使用 t.Run 和表格驅動測試可以使測試代碼更有組織、更易於閱讀。
      2. 方便你輕鬆地添加、修改或刪除測試案例。

Write unit test for Create Account API

func TestCreateAccountAPI(t *testing.T) {
	account := randomAccount()

	testCases := []struct {
		name          string
		body          gin.H
		buildStubs    func(store *mockdb.MockStore)
		checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
	}{
		{
			name: "OK",
			body: gin.H{
				"owner":    account.Owner,
				"currency": account.Currency,
			},
			buildStubs: func(store *mockdb.MockStore) {
				arg := db.CreateAccountParams{
					Owner:    account.Owner,
					Balance:  0,
					Currency: account.Currency,
				}
				store.EXPECT().
					CreateAccount(gomock.Any(), gomock.Eq(arg)).
					Times(1).
					Return(account, nil)
			},
			checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusOK, recorder.Code)
				requireBodyMatchAccount(t, recorder.Body, account)
			},
		},
		{
			name: "InternalError",
			body: gin.H{
				"owner":    account.Owner,
				"currency": account.Currency,
			},
			buildStubs: func(store *mockdb.MockStore) {
				arg := db.CreateAccountParams{
					Owner:    account.Owner,
					Balance:  0,
					Currency: account.Currency,
				}
				store.EXPECT().
					CreateAccount(gomock.Any(), gomock.Eq(arg)).
					Times(1).
					Return(db.Account{}, sql.ErrConnDone)
			},
			checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusInternalServerError, recorder.Code)
			},
		},
		{
			name: "InvalidCurrency",
			body: gin.H{
				"owner":    account.Owner,
				"currency": "invalid",
			},
			buildStubs: func(store *mockdb.MockStore) {
				arg := db.CreateAccountParams{
					Owner:    account.Owner,
					Balance:  0,
					Currency: "invalid",
				}
				store.EXPECT().
					CreateAccount(gomock.Any(), gomock.Eq(arg)).
					Times(0)
			},
			checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusBadRequest, recorder.Code)
			},
		},
	}
	for i := range testCases {
		tc := testCases[i]

		t.Run(tc.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			store := mockdb.NewMockStore(ctrl)
			tc.buildStubs(store)

			server := NewServer(store)
			recorder := httptest.NewRecorder()

			// Marshal body data to JSON
			data, err := json.Marshal(tc.body)
			require.NoError(t, err)

			url := "/accounts"
			request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
			require.NoError(t, err)

			server.router.ServeHTTP(recorder, request)
			tc.checkResponse(t, recorder)
		})
	}
}
  • Code Flow:
    1. 初始化:
      • 定義一個名為 TestCreateAccountAPI 的函數,它接受一個測試物件 t 作為參數。
      • 利用 randomAccount 函數創建一個隨機賬戶,用來為賬戶欄位生成隨機值。
    2. 定義測試案例:
      • 創建一個測試案例結構的切片,每個結構包含:
        • name: 字串,表示測試案例的名稱。
        • body: 一個 map,表示要在 HTTP 請求中發送的請求主體。
        • buildStubs: 一個函數,用來為 mock store 建立 stubs(模擬預期)。
        • checkResponse: 一個函數,用來檢查從 HTTP 請求收到的回應。
    3. 遍歷測試案例:
      • 使用迴圈遍歷每個測試案例。
    4. 執行每個測試案例:
      • 在迴圈內,對於每個測試案例:
        • 使用 t.Run 創建一個新的子測試,其中 tc.name 用作子測試的名稱。
        • 創建一個新的 mock 控制器並初始化 mock store。
        • 調用當前測試案例的 buildStubs 函數來設置模擬預期。
        • 使用 mock store 創建一個新的伺服器。
        • 創建一個 HTTP 記錄器來記錄 HTTP 回應。
        • 將請求主體編碼為 JSON 並創建一個 HTTP 請求。
        • 伺服器處理 HTTP 請求,並記錄回應。
        • 調用當前測試案例的 checkResponse 函數來檢查回應。
  • Test Cases:
    1. OK 案例:
      • 名稱: OK
      • 主體: 包含隨機賬戶的有效擁有者和貨幣欄位。
      • Stub: 預期 CreateAccount 方法被調用一次,帶有特定的參數,並返回一個有效的賬戶和無錯誤。
      • 檢查回應: 檢查 HTTP 狀態碼為 200 (OK) 並且回應主體與賬戶詳細信息相匹配。
    2. 內部錯誤案例:
      • 名稱: InternalError
      • 主體: 包含隨機賬戶的有效擁有者和貨幣欄位。
      • Stub: 預期 CreateAccount 方法被調用一次,帶有特定的參數,並返回內部服務器錯誤(sql.ErrConnDone)。
      • 檢查回應: 檢查 HTTP 狀態碼為 500(內部服務器錯誤)。
    3. 無效貨幣案例:
      • 名稱: InvalidCurrency
      • 主體: 包含隨機賬戶的有效擁有者欄位,但貨幣欄位無效。
      • Stub: 不預期調用 CreateAccount 方法,因為貨幣是無效的。
      • 檢查回應: 檢查 HTTP 狀態碼為 400(請求錯誤)。

Write unit test for List Account API

func TestListAccountAPI(t *testing.T) {
	n := 5
	accounts := make([]db.Account, n)
	for i := range accounts {
		accounts[i] = randomAccount()
	}

	type Query struct {
		pageID   int32
		pageSize int32
	}

	testCases := []struct {
		name          string
		query         Query
		buildStubs    func(store *mockdb.MockStore)
		checkResponse func(t *testing.T, recoder *httptest.ResponseRecorder)
	}{
		{
			name: "OK",
			query: Query{
				pageID:   1,
				pageSize: int32(n),
			},
			buildStubs: func(store *mockdb.MockStore) {
				arg := db.ListAccountsParams{
					Limit:  int32(n),
					Offset: 0,
				}
				store.EXPECT().
					ListAccounts(gomock.Any(), gomock.Eq(arg)).
					Times(1).
					Return(accounts, nil)
			},
			checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusOK, recorder.Code)
				requireBodyMatchAccounts(t, recorder.Body, accounts)
			},
		},
		{
			name: "InternalError",
			query: Query{
				pageID:   1,
				pageSize: int32(n),
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					ListAccounts(gomock.Any(), gomock.Any()).
					Times(1).
					Return([]db.Account{}, sql.ErrConnDone)
			},
			checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusInternalServerError, recorder.Code)
			},
		},
		{
			name: "InvalidPageID",
			query: Query{
				pageID:   -1,
				pageSize: int32(n),
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					ListAccounts(gomock.Any(), gomock.Any()).
					Times(0)
			},
			checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusBadRequest, recorder.Code)
			},
		},
		{
			name: "InvalidPageSize",
			query: Query{
				pageID:   1,
				pageSize: 100000,
			},
			buildStubs: func(store *mockdb.MockStore) {
				store.EXPECT().
					ListAccounts(gomock.Any(), gomock.Any()).
					Times(0)
			},
			checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) {
				require.Equal(t, http.StatusBadRequest, recorder.Code)
			},
		},
	}

	for i := range testCases {
		tc := testCases[i]

		t.Run(tc.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			store := mockdb.NewMockStore(ctrl)
			tc.buildStubs(store)

			server := NewServer(store)
			recorder := httptest.NewRecorder()

			url := fmt.Sprintf("/accounts?page_id=%d&page_size=%d", tc.query.pageID, tc.query.pageSize)
			request, err := http.NewRequest(http.MethodGet, url, nil)
			require.NoError(t, err)

			server.router.ServeHTTP(recorder, request)
			tc.checkResponse(t, recorder)
		})
	}
}
  • Code Flow:
    1. 函數初始化:
      • 定義一個 TestListAccountAPI 測試函數,並創建一個包含5個隨機賬戶的列表。
    2. 定義查詢結構和測試案例:
      • 定義一個名為 Query 的結構,其中包含 pageID 和 pageSize 作為查詢參數。
      • 定義一系列測試案例,每個案例包含名稱、查詢條件、建立模擬存儲期望的函數和檢查回應的函數。
    3. 遍歷測試案例:
      • 透過循環遍歷每一個測試案例。
    4. 執行每個測試案例:
      • 在循環中,針對每一個測試案例:
        • 使用 t.Run 創建一個新的子測試,並使用 tc.name 作為子測試的名稱。
        • 創建一個新的 mock 控制器和 mock store。
        • 調用當前測試案例的 buildStubs 函數來設定 mock store 的期望行為。
        • 創建一個新的伺服器和 HTTP 記錄器。
        • 根據測試案例的查詢條件創建一個新的 HTTP 請求。
        • 使伺服器處理 HTTP 請求並記錄回應。
        • 調用 checkResponse 函數來檢查 HTTP 回應。
  • Test Cases:
    1. OK 案例:
      • 名稱: OK
      • 查詢: 第1頁,每頁有5個賬戶。
      • Stub: 設定期望 ListAccounts 方法將被呼叫一次,並返回5個賬戶和無錯誤。
      • 檢查回應: 確認 HTTP 狀態碼是 200(OK),並且回應的賬戶列表與預期相符。
    2. 內部錯誤案例:
      • 名稱: InternalError
      • 查詢: 第1頁,每頁有5個賬戶。
      • Stub: 設定期望 ListAccounts 方法將被呼叫一次,但返回內部服務器錯誤(sql.ErrConnDone)。
      • 檢查回應: 確認 HTTP 狀態碼是 500(內部服務器錯誤)。
    3. 無效頁碼案例:
      • 名稱: InvalidPageID
      • 查詢: 第-1頁(無效),每頁有5個賬戶。
      • Stub: 由於頁碼是無效的,所以不期望 ListAccounts 方法被調用。
      • 檢查回應: 確認 HTTP 狀態碼是 400(請求錯誤)。
    4. 無效頁面大小案例:
      • 名稱: InvalidPageSize
      • 查詢: 第1頁,每頁有100000個賬戶(無效的大小)。
      • Stub: 由於頁面大小是無效的,所以不期望 ListAccounts 方法被調用。
      • 檢查回應: 確認 HTTP 狀態碼是 400(請求錯誤)。

Issues:


上一篇
[Day 24] Mock DB for testing HTTP API in Go and achieve 100% coverage Part 3
下一篇
[Day 26] Implement transfer money API with a custom params validator in Go
系列文
Techschool Goalng Backend Master Class 的學習記錄 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言